Skip to content

feat(speechify): streaming TTS with word-level timestamps via official SDK#6327

Open
luke-speechify wants to merge 9 commits into
livekit:mainfrom
luke-speechify:lo/speechify-streaming-tts
Open

feat(speechify): streaming TTS with word-level timestamps via official SDK#6327
luke-speechify wants to merge 9 commits into
livekit:mainfrom
luke-speechify:lo/speechify-streaming-tts

Conversation

@luke-speechify

@luke-speechify luke-speechify commented Jul 6, 2026

Copy link
Copy Markdown

Summary

Upgrades the livekit-plugins-speechify TTS plugin to stream audio with word-level timestamps, using Speechify's official speechify-api SDK.

Previously the plugin was non-streaming (streaming=False) and used raw aiohttp against /audio/stream (binary audio only, no timing metadata). Speechify's word-level speech marks are only returned by the batch /audio/speech endpoint, so this implementation chunks streamed input into per-sentence sequential /audio/speech calls, emitting audio and aligned word timestamps as each sentence completes — giving near-streaming time-to-first-audio and aligned_transcript support.

Changes

  • TTSCapabilities(streaming=True, aligned_transcript=True)
  • SynthesizeStream: sentence-chunked sequential synthesis with cumulative word-mark time offsets
  • ChunkedStream: one-shot synthesis with word marks
  • Uses the official speechify-api vendor SDK instead of hand-rolled HTTP
  • Provider-ownership metadata (following the livekit-plugins-gnani precedent): Repository/Issues point at Speechify's maintenance repo

Maintenance & distribution

This plugin is maintained by Speechify and distributed by LiveKit as part of livekit/agents. Speechify keeps a mirror of the plugin source at Speechify-AI/livekit-plugin-python for maintenance and issue triage, and proposes changes upstream here. The canonical, user-installable package remains livekit-plugins-speechify, published by LiveKit — there is no separate Speechify-published package.

Verification

  • ruff check + ruff format --check clean
  • mypy clean
  • Live synthesis verified against the Speechify API (synthesize + stream paths); audio transcribes back at WER 0.0 via the canonical tests/test_tts.py round-trip
  • Word-mark timestamps verified monotonic across sentence-chunk boundaries

Happy to adjust to match maintainer preferences.

@luke-speechify
luke-speechify requested a review from a team as a code owner July 6, 2026 12:36
@CLAassistant

CLAassistant commented Jul 6, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

@luke-speechify
luke-speechify marked this pull request as draft July 6, 2026 12:56
…l SDK

Upgrade the Speechify plugin to stream audio with aligned word-level
timestamps. Synthesis now uses the official speechify-api SDK and chunks
streamed input into sequential /audio/speech calls, emitting audio and
timed transcripts per sentence (streaming + aligned_transcript
capabilities). Route maintenance to the Speechify-owned repository.
@luke-speechify
luke-speechify force-pushed the lo/speechify-streaming-tts branch from 1745975 to c008b99 Compare July 6, 2026 13:23
@luke-speechify
luke-speechify marked this pull request as ready for review July 13, 2026 20:29
devin-ai-integration[bot]

This comment was marked as resolved.

@luke-speechify
luke-speechify force-pushed the lo/speechify-streaming-tts branch from d84105d to dff6ed7 Compare July 13, 2026 21:15
luke-speechify added a commit to luke-speechify/agents that referenced this pull request Jul 13, 2026
Addresses Devin review feedback on livekit#6327:

- SynthesizeStream: flush after each sentence's audio push. Without this,
  the AudioEmitter's tail buffer holds back the last ~10ms of each sentence
  until the next arrives, adding gaps between sentences and undermining
  the near-streaming promise. Matches the canonical stream_adapter pattern.
- _raise_from: only wrap ConnectionError as APIConnectionError (which the
  framework retries). Previously any Exception - base64 decode failures,
  AttributeError from an unexpected response shape - was misclassified as
  a retryable connection error, so the framework would retry a request
  that would deterministically fail again. Unknown exceptions now
  propagate as-is.
luke-speechify added a commit to Speechify-AI/demos that referenced this pull request Jul 13, 2026
speechify-api v3.0.0 renamed the constructor kwarg from api_key to token.

- audiobook-pipeline/requirements.txt: speechify-api>=2.0.0 -> >=3.0.1
- audiobook-pipeline/main.py: api_key -> token (env lookup + guard +
  Speechify(token=...))
- pipecat-agent-speechify/verify_stream.py: api_key -> token in the raw-HTTP
  smoke test (variable + Bearer header f-string) for symmetry with the SDK
  side; this script does not use the SDK but its local variable was still
  named api_key
- livekit-agent-speechify-python/README.md: the note on the plugin's public
  API says `token=` now (matches livekit/agents#6327's renamed param)
Editorial year was regressed 2024 -> 2023 while making unrelated content
changes. Restoring the year main already carries.
…GetVoice re-export)

The SDK v3.0.0 renamed the auth kwarg from api_key to token and the type-check
job was failing on the v2-era `AsyncSpeechify(api_key=...)` call plus the
implicit `GetVoice` re-export from tts.py. Bumps the pin so old resolutions
don't sneak the v2 shape back in.

- pyproject.toml: speechify-api>=2.0.0 -> >=3.0.1
- tts.py: rename public `api_key` param to `token`; pass through to
  AsyncSpeechify(token=...); update docstring + error message
- __init__.py: re-export GetVoice from speechify.types.get_voice directly
  instead of laundering through .tts (fixes mypy no-implicit-reexport)
- uv.lock: refreshed with speechify-api 3.0.1
Addresses Devin review feedback on livekit#6327:

- SynthesizeStream: flush after each sentence's audio push. Without this,
  the AudioEmitter's tail buffer holds back the last ~10ms of each sentence
  until the next arrives, adding gaps between sentences and undermining
  the near-streaming promise. Matches the canonical stream_adapter pattern.
- _raise_from: only wrap ConnectionError as APIConnectionError (which the
  framework retries). Previously any Exception - base64 decode failures,
  AttributeError from an unexpected response shape - was misclassified as
  a retryable connection error, so the framework would retry a request
  that would deterministically fail again. Unknown exceptions now
  propagate as-is.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants